This repository has no description
1import type { Did } from "@atcute/lexicons/syntax";
2import { createBobbinClient } from "$lib/api/client";
3import { fetchPage } from "$lib/api/pagination";
4import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich";
5import { type RepoRecord, type RecordList } from "$lib/api/records";
6import { IdentityCache } from "$lib/api/identity";
7import { didFromUri, rkeyFromUri } from "$lib/api/uri";
8import { toHttpError } from "$lib/api/load";
9import type { SearchPage } from "$lib/api/search";
10import type { BobbinContext } from "$lib/api/client";
11import { type VouchRecord } from "$lib/api/graph";
12import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star";
13import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string";
14import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow";
15import type {
16 RepoCardData,
17 StringCardData,
18 PersonData,
19 VouchData,
20 StarData
21} from "$lib/components/profile/types";
22import type { PageLoad } from "./$types";
23
24const PAGE_LIMIT = 50;
25
26const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" };
27const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" };
28const FOLLOW_STATS: LinkDescriptor[] = [
29 { source: "sh.tangled.graph.follow:subject", type: "count" },
30 { source: "sh.tangled.graph.follow:.repo", type: "count" }
31];
32const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" };
33
34const TABS = [
35 "overview",
36 "repos",
37 "starred",
38 "strings",
39 "followers",
40 "following",
41 "vouches"
42] as const;
43type Tab = (typeof TABS)[number];
44
45const normalizeTab = (raw: string | null): Tab =>
46 TABS.includes(raw as Tab) ? (raw as Tab) : "overview";
47
48interface ListItem {
49 uri: string;
50 value: unknown;
51}
52
53const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => {
54 const value = item.value as RepoRecord;
55 return {
56 rkey: rkeyFromUri(item.uri),
57 name: rkeyFromUri(item.uri),
58 repoDid: value.repoDid ?? "",
59 ownerHandle,
60 description: value.description,
61 knot: value.knot,
62 createdAt: value.createdAt
63 };
64};
65
66const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => {
67 const repo = toRepoCard(item, ownerHandle);
68 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null };
69 const stars = countOf(stats, repo.repoDid, STAR_COUNT.source);
70 const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source);
71 return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri };
72};
73
74const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => {
75 const value = item.value as ShTangledString.Main;
76 return {
77 rkey: rkeyFromUri(item.uri),
78 ownerHandle,
79 filename: value.filename,
80 description: value.description,
81 createdAt: value.createdAt,
82 lines: value.contents?.split("\n").length ?? 1
83 };
84};
85
86// the sidecar already carries follower counts and viewer status, so this costs
87// no extra requests
88const resolvePeople = async (
89 ctx: BobbinContext,
90 dids: string[],
91 stats: Stats,
92 viewerDid?: string
93): Promise<PersonData[]> => {
94 const cache = new IdentityCache(ctx);
95 const unique = [...new Set(dids)];
96
97 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null)));
98
99 const byDid = new Map<string, PersonData>();
100 unique.forEach((did, index) => {
101 const doc = docs[index];
102 const followers = countOf(stats, did, "sh.tangled.graph.follow:subject");
103 const following = countOf(stats, did, "sh.tangled.graph.follow:.repo");
104 const isSelf = viewerDid === did;
105 const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source);
106 const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri;
107 byDid.set(
108 did,
109 doc
110 ? {
111 did: doc.did,
112 handle: doc.handle,
113 followers,
114 following,
115 isSelf,
116 viewerFollowRkey
117 }
118 : { did, handle: did, followers, following, isSelf, viewerFollowRkey }
119 );
120 });
121 return unique.map((did) => byDid.get(did) as PersonData);
122};
123
124const resolveVouches = async (
125 ctx: BobbinContext,
126 items: ListItem[],
127 direction: "incoming" | "outgoing"
128): Promise<VouchData[]> => {
129 const cache = new IdentityCache(ctx);
130 return Promise.all(
131 items.map(async (item): Promise<VouchData> => {
132 const value = item.value as VouchRecord;
133 const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri);
134 const doc = await cache.resolve(otherDid).catch(() => null);
135 return {
136 uri: item.uri,
137 did: otherDid,
138 handle: doc?.handle ?? otherDid,
139 kind: value.kind === "denounce" ? "denounce" : "vouch",
140 direction,
141 reason: value.reason,
142 createdAt: value.createdAt
143 };
144 })
145 );
146};
147
148const resolveStars = async (
149 ctx: BobbinContext,
150 items: ListItem[],
151 viewerDid?: string
152): Promise<StarData[]> => {
153 const cache = new IdentityCache(ctx);
154 const repoDids = [
155 ...new Set(
156 items
157 .map((item) => (item.value as ShTangledFeedStar.Main).subject)
158 .flatMap((s) => (s && "did" in s && s.did ? [s.did] : []))
159 )
160 ];
161 const enriched =
162 repoDids.length > 0
163 ? await enrich<RecordList<RepoRecord>>(ctx, {
164 xrpc: "sh.tangled.repo.getReposByRepoDids",
165 params: { dids: repoDids },
166 enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT],
167 ...(viewerDid ? { viewer: viewerDid } : {})
168 })
169 : { output: { items: [] }, stats: {} as Stats };
170 const reposByDid = new Map(
171 enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item])
172 );
173 const resolved = await Promise.all(
174 items.map(async (item): Promise<StarData | null> => {
175 const value = item.value as ShTangledFeedStar.Main;
176 const subject = value.subject;
177 if (subject && "did" in subject && subject.did) {
178 const repo = reposByDid.get(subject.did);
179 if (!repo) return null;
180 const ownerDid = didFromUri(repo.uri);
181 const owner = await cache.resolve(ownerDid).catch(() => null);
182 return {
183 kind: "repo",
184 uri: item.uri,
185 createdAt: value.createdAt,
186 repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats)
187 };
188 }
189 if (subject && "uri" in subject && subject.uri) {
190 const ownerDid = didFromUri(subject.uri);
191 const owner = await cache.resolve(ownerDid).catch(() => null);
192 return {
193 kind: "string",
194 uri: item.uri,
195 createdAt: value.createdAt,
196 ownerHandle: owner?.handle ?? ownerDid,
197 rkey: rkeyFromUri(subject.uri)
198 };
199 }
200 return null;
201 })
202 );
203 return resolved.filter((star): star is StarData => star !== null);
204};
205
206export const load: PageLoad = async (event) => {
207 const parent = await event.parent();
208 const tab = normalizeTab(event.url.searchParams.get("tab"));
209
210 if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } };
211
212 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch });
213 const did = parent.identity.did as Did;
214 const handle = parent.identity.handle;
215
216 try {
217 switch (tab) {
218 case "repos": {
219 const q = event.url.searchParams.get("q")?.trim();
220 const viewerDid = parent.auth?.did;
221 const viewerEnrich = viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT];
222 if (!q) {
223 const enriched = await enrich<RecordList<RepoRecord>>(ctx, {
224 xrpc: "sh.tangled.repo.listRepos",
225 params: { subject: did, limit: PAGE_LIMIT },
226 enrich: viewerEnrich,
227 ...(viewerDid ? { viewer: viewerDid } : {})
228 });
229 return {
230 tab,
231 repos: enriched.output.items.map((item) =>
232 resolveRepoCard(item, handle, enriched.stats)
233 )
234 };
235 }
236 const enriched = await enrich<SearchPage>(ctx, {
237 xrpc: "sh.tangled.search.query",
238 params: { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT },
239 enrich: viewerEnrich,
240 ...(viewerDid ? { viewer: viewerDid } : {})
241 });
242 return {
243 tab,
244 repos: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats))
245 };
246 }
247 case "strings": {
248 const page = await fetchPage(ctx, "sh.tangled.string.listStrings", {
249 subject: did,
250 limit: PAGE_LIMIT
251 });
252 return { tab, strings: page.items.map((item) => toStringCard(item, handle)) };
253 }
254 case "followers": {
255 const viewerDid = parent.auth?.did;
256 const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, {
257 xrpc: "sh.tangled.graph.listFollows",
258 params: { subject: did, limit: PAGE_LIMIT },
259 enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS,
260 ...(viewerDid ? { viewer: viewerDid } : {})
261 });
262 const dids = enriched.output.items.map((item) => didFromUri(item.uri));
263 return {
264 tab,
265 people: await resolvePeople(ctx, dids, enriched.stats, viewerDid)
266 };
267 }
268 case "following": {
269 const viewerDid = parent.auth?.did;
270 const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, {
271 xrpc: "sh.tangled.graph.listFollowsBy",
272 params: { subject: did, limit: PAGE_LIMIT },
273 enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS,
274 ...(viewerDid ? { viewer: viewerDid } : {})
275 });
276 const dids = enriched.output.items.map(
277 (item) => (item.value as ShTangledGraphFollow.Main).subject
278 );
279 return {
280 tab,
281 people: await resolvePeople(ctx, dids, enriched.stats, viewerDid)
282 };
283 }
284 case "vouches": {
285 const [incomingPage, outgoingPage] = await Promise.all([
286 fetchPage(ctx, "sh.tangled.graph.listVouches", { subject: did, limit: PAGE_LIMIT }),
287 fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { subject: did, limit: PAGE_LIMIT })
288 ]);
289 const [incoming, outgoing] = await Promise.all([
290 resolveVouches(ctx, incomingPage.items, "incoming"),
291 resolveVouches(ctx, outgoingPage.items, "outgoing")
292 ]);
293 const vouches = [...incoming, ...outgoing].sort(
294 (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
295 );
296 return {
297 tab,
298 vouches,
299 isSelf: parent.auth?.did === did,
300 profileHandle: handle
301 };
302 }
303 case "starred": {
304 const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", {
305 subject: did,
306 limit: PAGE_LIMIT
307 });
308 return {
309 tab,
310 stars: await resolveStars(ctx, page.items, parent.auth?.did)
311 };
312 }
313 case "overview":
314 default: {
315 const viewerDid = parent.auth?.did;
316 const enriched = await enrich<RecordList<RepoRecord>>(ctx, {
317 xrpc: "sh.tangled.repo.listRepos",
318 params: { subject: did, limit: PAGE_LIMIT },
319 enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT],
320 ...(viewerDid ? { viewer: viewerDid } : {})
321 });
322
323 const pinnedKeys = parent.profile?.pinnedRepositories ?? [];
324 const byKey = new Map<string, ListItem>();
325 for (const item of enriched.output.items) {
326 const value = item.value as RepoRecord;
327 if (value.repoDid) byKey.set(value.repoDid, item);
328 byKey.set(item.uri, item);
329 }
330 const pinnedItems = pinnedKeys
331 .map((key) => byKey.get(key))
332 .filter((item): item is ListItem => item !== undefined);
333 const pinned = pinnedItems.map((item) => resolveRepoCard(item, handle, enriched.stats));
334
335 return { tab: "overview" as const, overview: { pinned } };
336 }
337 }
338 } catch (cause) {
339 console.error("Page load error:", cause);
340 toHttpError(cause, "Could not load profile data");
341 }
342};